fix(factors): drop residual 5min buckets in amp_marginal_anomaly_vol - #112
Merged
Conversation
CORRECTNESS FIX, not a refactor step. `amp_marginal_anomaly_vol` is the ONE
minute factor of the eleven that DERIVES coarser (5min) bars from the 1min cache,
and its value depended on the CALLER'S LOADING GEOMETRY.
`resample_intraday_bars` buckets 1min bars by `ceil(bar_end, freq)` and then
reports each bucket's REAL span, so a bucket that runs out of 1min bars is
emitted as a SHORTER bar rather than dropped (its docstring says so). Two callers,
two geometries:
* legacy runner / panel freeze -- WHOLE-DAY 1min bars. The 14:46..14:50 bucket
is complete, so it is removed by the availability rule (available_time 14:51
> the 14:50 cutoff). Last visible bar of the day: the complete 14:45 bucket.
* D4 materializer -- 1min bars PRE-TRUNCATED to `available_time <= 14:50`, i.e.
through the 14:49 bar. The same bucket now holds FOUR constituents, ends at
14:49 and PASSES the availability rule. Every day gained a fifth "5min bar"
that was really four minutes long.
Every statistic here is bar-length sensitive (`amp = high/low - 1`,
`r = close_t/close_{t-1} - 1`) and they are POOLED over 20 trading days, so the
short bar shifts the pool mean / std and the selected-bar return std
SYSTEMATICALLY, with a sign -- not as noise.
THE LEGACY PATH IS THE DEFINITION-FAITHFUL ONE, which is why this is a fix and
not a re-freeze. The definition is "5min bars truncated at 14:50"; the 14:45-14:50
window has not finished at 14:50, so it is genuinely unknowable then. Dropping it
is the definition, and a partial stand-in for it is not a cheaper version of the
same bar -- it is a different bar.
FIX: `_complete_grid_bars` keeps only derived bars whose window reached its `freq`
grid boundary. Because the emitted `bar_end` is the last constituent's, equality
with `ceil(bar_end, freq)` is an EXACT test for "this window closed". Applied in
the factor's own compute, right after the derivation:
* `resample_intraday_bars` is NOT touched. Its only production consumer is this
factor (grep: two other references are tests), but it is a general data-layer
primitive whose residual-emitting behaviour is documented and directly
covered by tests/test_intraday_aggregate.py -- and completeness is a property
of THIS factor's definition, so it belongs with the factor's math (D2).
* NOT patched in the materializer either: that would scatter the factor
definition into its callers, which is the shape of the defect being fixed.
MEASURED ON REAL CACHED BARS (tmp/context/f1_ab_geometry.py, cache-only, no live
calls), 000008.SZ around the reconcile's reported cell, 61 dates:
pre-fix : whole-day 0.004156551477727235 | pre-truncated 0.0041410493817719074
-> 51 of 61 dates differ, max|diff| 1.42e-04 on values ~4e-3
post-fix: whole-day 0.004156551477727235 | pre-truncated 0.004156551477727235
-> 0 of 61 differ. The two geometries agree bitwise, ON THE LEGACY
VALUE.
WHY NO PUBLISHED VALUE MOVES. The chain matters, because a residual bucket has
TWO possible causes and only one of them can reach the legacy geometry:
1. TRUNCATION. The materializer pre-truncates at 14:50, so the [14:46,14:50]
bucket keeps four constituents and ends at 14:49, off-grid. This happens on
EVERY trading day, and it is the entire source of the 729,029 unclassified
cells the panels reconcile reported before this fix.
2. A DATA GAP inside a session. A bucket whose trailing minutes are missing also
ends off-grid. This is the only cause that can occur WITHOUT truncation.
The legacy / frozen geometry feeds WHOLE-DAY bars, so cause 1 cannot arise there;
its only exposure is cause 2. Cause 2 is measured to be empty on this cache:
scanning `bar_end` straight off the minute store over the evaluation window
(tmp/context/f1_residual_probe.py, 591 symbols, 649,681 (symbol, day) pairs) finds
ZERO residual buckets -- the cached 1min bars are grid-contiguous within each
session. With both causes excluded, the filter is a NO-OP on the legacy path, so
the frozen exec baseline and the D1 frozen panels are untouched.
`test_whole_day_gapless_bars_leave_the_completeness_filter_a_noop` proves the
conditional structurally (gapless whole-day input -> `assert_frame_equal`, not one
row dropped); the probe supplies its premise on real data; the A/B above
corroborates by converging onto the frozen value.
NOTE the probe measures cause 2 ONLY -- it applies no cutoff to its input. Read as
a statement about residual buckets in general it would contradict the 729,029,
which is cause 1 in a geometry the probe never looks at.
So no `FactorCorrection` is declared and `spec.version` stays 1.0: that field
means "PUBLISHED values were superseded", and asserting a supersession that did
not happen would put a false statement into every artifact carrying it. Contrast
PR #103 (jump), where the published artifacts THEMSELVES were computed without the
cutoff and the correction was mandatory.
WHAT WAS ACTUALLY CONTAMINATED, so nobody reads this as "nothing ever went wrong":
the live `artifacts/reports/factor_eval_amp_marginal_anomaly_vol_20_exec_*`
written by the D5 C5 full run ARE dirty -- they were produced by the defective
new engine, and this branch's rerun overwrites them. The frozen exec baseline and
the eleven-factor v0.9 table are NOT affected. The defect never reached a
published result: the D5 C5 reconcile caught it, which is what it is for.
TESTS (tests/test_amp_marginal_residual_bucket.py, 9): the three directions plus
purity. Note the interior-hole test -- a bucket missing MIDDLE minutes still spans
its window and is KEPT -- which pins that the rule is "the window closed", not
"five constituents", so a count-based reading cannot creep in later. Every
assertion is preceded by a precondition that the fixture actually contains the
thing being guarded (a residual bucket really is produced; a finite value really
exists on both sides), so none can pass vacuously.
Mutation evidence, all run (tmp/context/f1_mutation.sh applies each to the real
source, greps for the witness, runs, reverts, and re-checks sha256):
* filter made a no-op (== the pre-fix behaviour): 5 red. The two whole-day
tests stay GREEN -- which is the claim "the legacy path does not move",
asserted rather than assumed.
* completeness re-read as full SPAN (would also drop interior-hole buckets):
the interior-hole test goes red, plus 9 of the EXISTING factor tests, whose
fixtures put one 1min bar per 5min grid point.
* the filter call removed from compute (wiring only): 2 red.
Node-ID differential main..HEAD: 2537 -> 2546, 0 removed, 9 added.
The precondition only asserted the 14:40 bucket EXISTS while its comment claimed the bucket is short two constituents. It now asserts bar_start moved 14:35 -> 14:37, so the test cannot pass on a fixture where the intended gap failed to land.
Two surfaces, one wording, after review corrected the causal chain.
THE CHAIN THAT WAS WRONG AS WRITTEN. The first report said "the cached 1min bars
are grid-contiguous within each session, THEREFORE the fix moves no legacy value".
That reads as a claim about residual buckets in general, and in that reading it
contradicts a known fact: the panels leg had 729,029 unclassified cells, so
residual buckets plainly existed. The probe was measuring something narrower.
A residual bucket has exactly TWO causes, reaching different callers:
1. TRUNCATION -- the materializer pre-truncates at 14:50, so [14:46,14:50] keeps
four constituents and ends at 14:49. One per trading day; the entire source
of the 729,029.
2. A DATA GAP inside a session -- the only cause that can occur without
truncation.
The legacy / frozen geometry feeds WHOLE-DAY bars, so cause 1 cannot arise there
and its only exposure is cause 2. The probe measures cause 2 (it applies no cutoff
to its input) and finds it empty: 591 symbols, 649,681 (symbol, day) pairs, zero
residual buckets. Both causes excluded -> no-op on the legacy path. The noop test
proves the conditional structurally; the probe supplies its premise on real data;
the A/B corroborates by converging onto the frozen value.
So the probe is not decoration and it is not the whole argument either: it is the
premise of the step that excludes cause 2. Stated without the geometry qualifier
it is simply false, which is how it read.
CATALOGUE (new section, APPENDED at end of file so a concurrent C5-audit append
conflicts mechanically rather than semantically; no existing line is touched).
The section exists mainly to prevent a specific mistake: whoever writes the C5
audit will meet the 729,029 in the run records, and if the catalogue is silent
they may well register it as a difference class to be named and bounded. It must
say the opposite -- F1 is a real engine defect, removed by a correctness fix, and
NEVER a named class. It carries the corrected chain, the per-cell A/B, the
post-fix reconcile counters (unclassified=0 with float_tail / threshold_flip /
flip_contamination all 0 -- nothing was absorbed by a tolerance class), the
no-FactorCorrection ruling with the contrast to PR #103, and the restated verdict.
It also names what WAS contaminated, so the section cannot be read as "nothing
ever went wrong": the live factor_eval_amp_marginal_* artifacts from the C5 full
run are dirty and this branch's rerun overwrites them; the frozen exec baseline
and the eleven-factor v0.9 table are not affected.
DOCSTRING: the same two-cause split, plus the explicit warning not to restate the
measurement as "there are no residual buckets" -- it says nothing about the
truncated geometry, where there is one per trading day.
NOTE this commit edits the factor module, so its code_hash changes and the store
entries filled by the previous run are invalidated even though no value moves.
The real rerun is therefore repeated at this tree, so the reported counters and
the warm store both correspond to the code being handed over rather than to a
superseded one.
…through
Review follow-up, TEXT ONLY -- no behaviour, no judgement threshold, no engine
code. Four fixes, batched into one commit because two of them touch the factor
module's docstring and therefore invalidate its code_hash (measured last round:
cold refill 550.9s vs warm 71.8s), so the real rerun is paid once.
MED-1 -- THE CENSUS SCOPE WAS OVERSTATED, and it was the load-bearing premise.
`f1_residual_probe.py --limit 600` sampled 600 of the ~5,782 CACHED symbol
directories, whose intersection with the 995-name evaluation universe is only
~102. Writing that up as "591 symbols in the evaluation window" invites reading
it as "591 of the 995". Since this premise is what excludes cause 2 (data-gap
residuals) and therefore what makes "no published value moves" true, its scope
has to be exact. Replaced by a FULL census that takes its grid from the frozen
panel itself (`tmp/context/f1_residual_census_full.py`): no sampling, no limit.
995/995 symbols | 1,159,263 (symbol, day) pairs | 0 residual buckets
1,159,263 is exactly `rows_frozen`, i.e. the census grid coincides cell-for-cell
with the panel the reconcile checks. Measured independently by implementer and
reviewer with different scripts; the numbers agree.
Also recorded: the panels leg already SUBSUMES this census (995 symbols x 1,191
non-warmup days all held to 1e-12). The census earns its place by covering the
other 19 days -- the warmup region, the one place the panels leg does not apply
that tolerance.
LOW-1 -- "no `spec.*` differences" was literally false. Measured: frozen carries
18 `spec.*` leaves, new carries 24. The 18 present on BOTH are value-identical
(including `spec.version` and `spec.description`); 0 removed; the 6 extra are
pure additions (`adjustment` / `lookback_depth` / `overnight_boundary` /
`requires[0..2]`), each `_is_registered_addition == True`, from the D1/D5b
contract rather than from this PR. Restated as "no `spec.*` VALUE changes" in
both places it appears -- this sentence is the argument for not needing a
jump-style correction route, and an argument propped up by a false sentence is
not an argument.
LOW-2 -- the docstring's reason did not match what the rule actually admits. The
criterion asks whether the GRID-BOUNDARY MINUTE IS PRESENT, not how many
constituents a bucket has, and `ceil(09:30, 5min)` is 09:30 -- so the opening
auction minute forms a bucket of ONE and is kept. One 1-minute bar is pooled as
a "5min bar" every trading day. Measured on 600000.SH over 2023-06: 20 of 20
days have exactly one kept sub-5-constituent bucket, always 09:30. Independent
cross-check from the other script: over the first 200 symbols the census counts
233,225 (symbol, day) pairs, the same number the reviewer counted as sub-5
"complete" buckets over the same 200 -- i.e. exactly one per symbol-day.
This sits in tension with the bar-length argument, and it STAYS: both geometries
keep it and so does the frozen baseline, so dropping it would MOVE PUBLISHED
VALUES. That is a separate correctness question owing its own PR and its own
evidence, not something to slip into this one. Said plainly in the docstring and
the catalogue so a later reader does not "fix" it in passing.
LOW-3 -- guard range written into the guard, per house rule. The equality test is
only meaningful where the `freq` grid aligns with the A-share session. Measured:
`{1,5,15,30}min` drop nothing; `60min` drops one bucket per day, because
`ceil(11:30, 60min)` is 12:00, so 11:01-11:30 ends off-grid and the whole morning
close would be discarded daily (600000.SH 2023-06: coarse 100 -> kept 80, every
dropped `bar_end` is 11:30). Latent, not live: `freq` defaults to the module
constant "5min" and both call sites use the default. A 60min caller would get
silent daily data loss rather than an error.
NIT-2 -- test name now matches its fixture. It removes 14:36/14:37, the LEADING
minutes of the 14:36..14:40 bucket, not interior ones; renamed to
`test_bucket_with_a_leading_gap_is_kept_because_its_window_still_closed`. The
first docstring line said "middle minutes" and was wrong in the same way, so it
is fixed too. The leading gap is in fact the stronger fixture: it moves
`bar_start` while leaving `bar_end` on the grid, which is exactly what separates
this rule from a span-based one -- and is why mutation B goes red on it.
DEFECT MAGNITUDE, restated upward. The example first published (000008.SZ, 61
dates, max|diff| 1.42e-04) was too small and too short a window. Re-measured on
600000.SH over the FULL evaluation window (1,213 dates, cache-only): pre-fix
567/1,213 dates differ, max|diff| 4.14e-04, max REL 1.456776e-01; post-fix
0/1,213 differ, max rel exactly 0. The reviewer, loading the pre- and post-fix
modules into one process via `git show main:` and feeding byte-identical inputs,
independently gets max rel 1.457e-01 and whole-day geometry 5/5 BITWISE-EQUAL --
so "no published value moves" is now DIRECTLY DEMONSTRATED, not merely inferred.
Node-ID differential main..HEAD: 2537 -> 2546, 0 removed, 9 added (the rename
stays inside the new file).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
correctness fix,单独成 PR,非因子层重构的一部分(照 PR #103
jump_amount_corr先例)。缺陷
amp_marginal_anomaly_vol_20是 11 个分钟因子里唯一派生 5min bar 的。available_time ≤ 14:50,compute 再 resample →resample_intraday_barsemit 一个残桶,于是每天多出一根只有 4 个成分的"5min bar"。振幅/收益类统计对 bar 长度敏感 ⇒ 系统性偏小,有方向。available_time = 14:51 > cutoff被整桶丢弃,当天最后一根是 14:45 的完整桶。因子定义是"5min bars 截断于 14:50" ⇒ 14:45–14:50 那个完整桶在 14:50 时点根本不可得,所以旧路径才是定义忠实的那个;bar 长度敏感的池化统计不该吃部分桶。
修法
_complete_grid_bars(coarse, freq),判据bar_end == bar_end.dt.ceil(freq),在 compute 内 resample 之后调用。resample_intraday_bars未改:它是通用 data-layer 原语、残桶行为被test_intraday_aggregate直接覆盖;而"完整桶"是本因子定义的性质(D2 约定:数学与声明同文件)。生产侧派生粗 bar 的只有本因子。结果(真实重跑,cache-only,
stk_mins_live_calls=0)float_tail / threshold_flip / flip_contamination全 0(没有靠任何容差类兜底)ok=4 warmup=1 failed=0;此前 FAIL 的 3 行现为 rel = 0.00e+00 精确verdict 重述(真跑出来的,不由相关系数推定)
Reject / Reject 未变,三轴标签全未变。 IC −0.042601→−0.042341、ICIR −0.446484→−0.443614、N_eff 1116.017→1115.866、periods 1199→1209。
warmup_left_extension聚合效应(评估日多了 10 个)。_bookclose的增量 ICIR 逐位复现冻结的 −0.311985(ICIR/CI/SE/N_eff 五项全同),把"引擎"与"书视图修正"分离干净——decision 书那 −0.304787 是 intended change。不追认旧 artifact
被取代的是 C5 全量跑产出的那份有缺陷的
factor_eval_amp_marginal_anomaly_vol_20_exec_*(已被重跑原地覆盖)。冻结/已发布的 exec 基线从来没被这个缺陷碰过。缺陷足迹不小:修复前 panels 有 729,029 格类外 finite-vs-finite;
600000.SH在完整评估窗口(1,213 日)上修复前 567/1,213 日不同、max rel1.456776e-01,修复后 0/1,213、max rel 0.0。若它当初发布出去,秩 IC 逐日封顶会让聚合看起来动得很小——那是稀释不是无害。为什么不 bump
spec.version/ 不加FactorCorrectionFactorCorrection的语义是「已发布的值被取代」。声明一次没发生的 supersession,等于往每份 artifact 里写一句假话。reports 腿实证支持:无一处
spec.*值变化(两侧共有的 18 个spec.*叶子值全同,含spec.version/spec.description;另 6 处是 D1/D5b 契约的预登记新增,非本 PR 引入)⇒ 不需要 jump 式更正注册路由。"已发布值不移动"是直接测得的,不靠推理:评审用
git show main:把修复前后两个模块载入同一进程、喂逐字节相同输入,5 只真实 symbol 的整日几何 5/5 BITWISE-EQ;预截几何则收敛到冻结那一侧。评审过程中修正的三处文档缺陷
rows_frozen、残桶 0),由实现方与评审两个独立脚本分别测得且逐项一致;初版错误如实记录在案,未悄悄换掉。_complete_grid_bars的理由与它实际放行的东西不一致:判据放行 09:30 集合竞价 bar(ceil(09:30,5min)=09:30⇒ 自成一桶、恰 1 个成分、算"完整")。即每个交易日都有一根 1 分钟 bar 被当 5min bar 池化(实测600000.SH2023-06:20/20 天各恰一个,全是 09:30)。行为不改——两种几何与冻结基线都保留它,丢掉它会移动已发布值,那是另一个 correctness 问题、要另开 PR。已在 docstring 与编目明写「已知且刻意不改,后人别顺手修」。{1,5,15,30}min与网格对齐(各实测 dropped=0);60min不对齐——ceil(11:30,60min)=12:00⇒ 11:01–11:30 每天被静默丢弃(实测 coarse 100 → dropped 20,丢的bar_end全是11:30)。今日freq是模块常量"5min"、两个调用点都用默认,故仅为潜在。测试
新文件
tests/test_amp_marginal_residual_bucket.py(9 测试,每个断言前都有 PRECONDITION 断言"被守的东西确实存在于 fixture")。四组 mutation 各有红/绿见证,实现方与评审各自独立写了一套(评审未复用实现方脚本):
resample_intraday_bars的bar_start是成分最小值、不是bar_end - freq)available_timenode-ID 差分:main 2537 → branch 2546,REMOVED 0 / ADDED 9。
评审
对抗性评审 APPROVE-WITH-NITS → 四条返工 → delta 复核 CLEARED-TO-MERGE,新发现 无。评审以 AST 比对(剥掉全部 docstring 后
ast.dump精确比)证明返工 commit7ba08e5零可执行改动,故其对2d23779的裁定原样覆盖当前 HEAD。Gates(lead 独立重跑,未采信 subagent 自报)
pytest -p no:warnings2546 passed ·ruffclean · phase0 锚 ic 0.9600 / annual 0.8408 ·validate-config32/32 ·exec_baseline_freeze --verify77/77 @ 45c14aa · secret scan 0 · diff 只碰 3 文件(+494/−4)· 对既有 docs 小节 0 删除(编目为纯追加)· 冻结基线一个字节未写留给 Step 2(本 PR 一行未碰
qt/factor_eval_reconcile.py)headline
max_rel_diff在任何分桶之前更新,却与unclassified=0并排打印,且result.diffs从不落盘 ⇒ 逐类 max 只能重算。本例中那个9.03e-01经两方独立复算完全落在已登记的warmup_left_extension内(由2021-07-15 688425.SH精确取得),但这个报告口径会诱导"容差没牙"的误读。建议 summary 行加逐类 max_rel。